Skip to content

inspector: --inspect-brk pauses at the start of the entry from JSC, with no injected debugger; statement - #44691

Draft
robobun wants to merge 8 commits into
mainfrom
robobun/99e5b18b/inspect-brk-pause-from-debugger
Draft

robobun wants to merge 8 commits into
mainfrom
robobun/99e5b18b/inspect-brk-pause-from-debugger

Conversation

@robobun

@robobun robobun commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #32591

Blocked on oven-sh/WebKit#776: a draft until the pin is a merged commit.

Problem

  • With --inspect-brk or BUN_INSPECT=<url>?break=1, the parser adds debugger; as line 1 of the entry (src/js_parser/parse/parse_entry.rs:1184). Each line moves down by one in the reported script, so a breakpoint by line pauses one statement early.
  • The start pause needs Debugger.setPauseOnDebuggerStatements. An entry that the parser does not print (// @bun, a compiled executable) gets none.

Fix

  • The parser adds nothing. reload_entry_point asks JSC to pause when the top-level code of the entry starts (Debugger__schedulePauseAtEntry, [JSC] The debugger can pause when a given script starts to run WebKit#776).
  • The pause is before the first statement, reason PauseOnNextStatement: 58 of 58 probe cells, 20 before.
  • Verified: test/cli/inspect/inspect-brk.test.ts (33 tests, 30 fail on 1.4.3-canary.1). Also the other 8 files of test/cli/inspect/.
  • Self-reviewed: 15 concerns raised, 13 addressed, 2 left for a maintainer (notes).

Background

  • The inspector reports positions in the transpiled text, which a client maps with the inline source map.
  • JSC calls Debugger::callEvent or willExecuteProgram when a function or a module starts. The request makes that call pause for one script.
  • Considered: print debugger; on line 1 beside the first statement (Fix inspector breakpoints landing one line early with --inspect-brk #32592). It keeps the opt-in and the entries with no pause.

Downsides

Notes

What the reporter of #32591 sees. The script of the issue, unchanged, prints BUG REPRODUCED on 1.4.3-canary.1: the first pause is the line-5 breakpoint at module code@5:1 with payload in its temporal dead zone. With this change the script of the entry has no injected line, and the first pause is the start pause at module code@1:1 ("reproduced": false). That client never saw a start pause, because it does not send Debugger.setPauseOnDebuggerStatements. After one Debugger.resume the breakpoint on line 5 pauses with payload.total equal to 10. The test "the entry has the text it has under --inspect-wait" does exactly this.

For a maintainer to decide.

  1. Should the start pause need Debugger.setBreakpointsActive? It does. JSC compiles debug hooks only while breakpoints are active, so without them nothing pauses: not Debugger.pause, not a debugger statement, not this. The client of Inspector Protocol does not emit Debugger.paused events #24254 (with Inspector.initialized added) still gets no pause. docs/runtime/debugger.mdx now says which commands a client sends. The question is asked in [JSC] The debugger can pause when a given script starts to run WebKit#776 too.
  2. Landing order with inspector: CDP on --inspect, node announce lines, Session, Profiler adapter, Network, exit handshake, async stacks (+9 tests, inspector 11%→22%) #34719, and with node: inspector/CDP + bun inspect v26 compat (+70 tests) #35396 and node: inspector/debugger v26 compat wave 3 — Network instrumentation, cluster inspect-port, in-process awaitPromise (+5 tests) #35539, which stack on it. They run --inspect-brk through a CDP adapter. node: inspector/CDP + bun inspect v26 compat (+70 tests) #35396 works around the injected statement: it starts an interactive bun inspect child with --inspect-wait, because with --inspect-brk "an interactive session stops twice on the first line", and its source-map code falls forward from the injected line. If this lands first, those workarounds can go, and a test of theirs that counts the injected line needs an update. If they land first, this PR makes that update.

The Bun side.

  • Debugger::schedule_pause_at_entry (src/jsc/Debugger.rs) runs at the start of reload_entry_point and reload_entry_point_for_test_runner, before the preloads. A preload that imports the entry, and a patched Module.runMain that loads the entry in a later tick, still pause. Only the first entry load of the process schedules: a --hot reload and the second test file do not pause. The next entry load withdraws a request that was not used.
  • Removed: the parser feature set_breakpoint_on_first_line, the same field of ParseOptions, the process-wide one-shot flag set_break_point_on_first_line() and its two readers, and the slot in the features hash of the runtime transpiler cache (EXPECTED_VERSION 34 to 35).
  • A worker has no debugger configuration, so it schedules nothing.
  • --help, the docs and the completions say "wait for a connection and pause before the first line of code".

What changes for a client.

  • The start pause does not need Debugger.setPauseOnDebuggerStatements. Its reason is PauseOnNextStatement (was DebuggerStatement), also when a breakpoint is at the same place.
  • The entry has the text it has under --inspect-wait (0 lines differ).
  • CommonJS: the pause is at the start of the module's function (generated 1:10), before its first statement. It was at the injected statement (2:3).
  • Entries that now pause: // @bun, // @bun @bun-cjs, an empty file, and a bun build --compile executable started with BUN_INSPECT=<url>?break=1, with and without bytecode. The --inspect-brk flag does not reach the VM of an executable until Forward --inspect and --dns-result-order to the VM in compiled executables #40703.
  • Unchanged: the static imports of an ES module entry run before the pause (Node 26 pauses in the first module that runs). A preload runs before the pause. bun test pauses in the first test file only.

The debug adapter of the VS Code extension (packages/bun-debug-adapter-protocol, driven the way VS Code drives it, "stopOnEntry": true, the "Debugger Statements" box not checked). Stops shown to the user:

Entry, breakpoint 1.4.3-canary.1 this PR
entry.ts, none none pause at line 1
entry.ts, line 1 none pause at line 1
entry.ts, line 3 none, or the breakpoint only pause at line 1, breakpoint at line 3
entry.cjs, line 3 breakpoint at line 3 pause at line 1, breakpoint at line 3

With the box checked, 1.4.3-canary.1 shows the entry stop of a CommonJS entry on line 2 (the injected line), and hides the entry stop of an ES module that has a breakpoint. The adapter puts a breakpoint of its own at 0:0 of a file that has breakpoints and resumes when it pauses, and a pause on the injected statement had that breakpoint's reason.

Measurements. x86-64 Linux. Release builds (LTO) of main bbdc5a519e0 and of this PR unless a line says otherwise.

  • Per module, no debugger: evaluate sites +0 instructions (Zig::GlobalObject::moduleLoaderEvaluate 112, EvalGlobalObject::moduleLoaderEvaluate 162, evaluateCommonJSModuleOnce 1,319, the same in both). Transpile path: transpile_source_code_inner 2,848 to 2,831 and TranspilerJob::run_from_worker_thread 1,638 to 1,618 (the atomic exchange is gone). Counted in objdump -d.
  • Per entry load: reload_entry_point 643 to 666 instructions.
  • New exports: 2 (Debugger__schedulePauseAtEntry, Debugger__cancelPauseAtEntry). Calls over a program of 500 modules: plain 0, --inspect-wait 0, --inspect-brk 1 (gdb hit counts, debug build).
  • JSC entry hooks: +2 instructions each with no request pending. Slow-path hits: 0 (--inspect-wait), 1 (--inspect-brk).
  • Cells with exactly one pause before any entry code: 58 of 58 (1.4.3-canary.1: 20 of 58, and 0 of 29 without Debugger.setPauseOnDebuggerStatements). An entry that starts with a class pauses at 1:1 with the class not initialised.
  • Step sequence from the start pause: 1:1, 2:1, 3:1 (ES module) and 1:10, 2:3, 3:3 (CommonJS). Repeat stops at the start position: 0.
  • Entry text lines that differ from --inspect-wait: 0. Extra start pauses after the first entry load: 0 (two test files, a --hot reload, a --hot reload that fails to parse and is fixed).
  • numberOfDFGCompiles(fib) with no start pause delivered (breakpoints inactive, not set, no Debugger.enable): 1. Under --inspect-wait: 1.
  • Release binary: text +1,780 bytes, data -64 bytes (size). bloaty is not installed here. Transpiler cache entries rewritten once: 20 of 20, then 0. WebKit prebuilts of the preview build: 42 of 42.

Self-review. The 15 concerns:

  1. The VS Code adapter hid the entry stop when a breakpoint was at the same place. Fixed: the pause keeps its reason. Tests: the four adapter tests and two protocol tests.
  2. A hoisted function of the entry, called by a dependency in an import cycle, took the pause. Fixed: only top-level code takes it. Test.
  3. A preload that imports the entry lost the pause. Fixed: the request is scheduled before the preloads. Test.
  4. A patched Module.runMain that loads the entry later lost the pause. Fixed: the request is not withdrawn when the load returns. Test.
  5. A request that the entry did not take fired later, in a function of a CommonJS entry. Fixed: the request ends when the entry starts. Test.
  6. registerCodeBlock saved more registers on the path with no request. Fixed: the slow path is out of line (+2 instructions).
  7. No test drove the debug adapter. Added.
  8. Fixes #32591 needed the qualification above.
  9. The claim for executables is for BUN_INSPECT=<url>?break=1 (Forward --inspect and --dns-result-order to the VM in compiled executables #40703).
  10. The static imports of the entry run before the pause: stated.
  11. Breakpoints are being hit at unexpected locations when using Bun for debugging #15131 and Debugger ignores breakpoint on first line #8282 are not reports of this defect: not used.
  12. The zsh completions had the old text: updated.
  13. The WebKit PR is open first, and this PR is a draft until the pin is merged.
  14. Open: whether the pause should need Debugger.setBreakpointsActive.
  15. Open: the landing order with inspector: CDP on --inspect, node announce lines, Session, Profiler adapter, Network, exit handshake, async stacks (+9 tests, inspector 11%→22%) #34719, node: inspector/debugger v26 compat wave 3 — Network instrumentation, cluster inspect-port, in-process awaitPromise (+5 tests) #35539 and node: inspector/CDP + bun inspect v26 compat (+70 tests) #35396.

Differences from the plan of the design review. No check of the loader of the entry: a .json, .toml, .yaml or text entry does not run, and an .html or .wasm entry has no JavaScript at its URL, so its request stays unused until the next entry load. The tests are in a new file, because no file had tests for this flag.

Found on the way, not in this PR.

  • A step over a top-level await fails ASSERT(m_pauseForAwaitInGenerator) in Debugger::willAwait in a debug build, and a release build does not stop on the next line. It happens under --inspect-wait from a debugger statement too. No open PR found.
  • The debug adapter shows no stop for a breakpoint on line 1 of the entry under ?wait=1: the user's breakpoint and the adapter's own 0:0 breakpoint are one location. It is the same on 1.4.3-canary.1.
  • test/cli/run/transpiler-cache.test.ts "--define invalidates cache" and "--drop invalidates cache" need more than 5 s in a debug build. They pass with --timeout 60000.

Not run. Windows and macOS. The tests use TCP on 127.0.0.1 and the adapter's own launch path, which has a Windows branch.

Related. #24254 (a client with no start pause), #4222 (the injected statement), #32592 (the earlier attempt: the statement on line 1 beside the first statement), #43409 (the entry of the node shim).


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/cli/inspect/inspect-brk.test.ts

`--inspect-brk` and `BUN_INSPECT=<url>?break=1` asked the parser to put a
`debugger;` statement in front of the entry. That statement pauses only
when the client enabled pause on debugger statements, it is not there
for an entry the parser does not read (`// @bun`, a compiled
executable), and it moves every line of the entry down by one.

The first entry load of the process now asks the debugger to pause when
the code of the entry starts to run (JSC `schedulePauseAtEntry`). The
request is taken back when the entry did not run or loads again. The
parser option, the one-shot flag of the transpiler paths and their slot
in the transpiler cache hash are gone.
…ever it runs

The request is scheduled before the preloads, so a preload that imports the
entry does not lose the pause. It is not withdrawn when the load returns, so
a patched Module.runMain that loads the entry later does not lose it either.
The next load of an entry withdraws a request that was not used.

WebKit 5d73d6291c: only the module code, the program code, or the wrapper
of a CommonJS module takes the request, and the pause keeps its reason when
a breakpoint is at the same place.
…y starts

WebKit cd9249323d: the request ends when the top-level code of the entry
starts to run, with a pause or without one. Before, an entry that started
while breakpoints were inactive left the request pending, and a later call
of a function of a CommonJS entry could take it.

The test for the two debugger-statement cases is now two tests, and the
compiled-executable tests run after the other tests.
The adapter puts a breakpoint of its own at the start of a file that has
breakpoints, and resumes when that one pauses. The tests run it with
stopOnEntry and check that the stop on entry shows, with and without a
breakpoint of the user in the entry.

Also: the tests of this file have a 30 s ceiling in debug and ASAN builds,
where each inspector process needs a second to start. The zsh completions
describe --inspect-brk as --help does.
@robobun

robobun commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:33 AM PT - Oct 7th, 2026

❌ @robobun, your commit 2647ed2 has 2 failures in Build #123746 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 44691

That installs a local version of the PR into your bun-44691 executable, so you can run:

bun-44691 --bun

@robobun

robobun commented Oct 7, 2026

Copy link
Copy Markdown
Collaborator Author

Status. Draft. Blocked on oven-sh/WebKit#776: the WebKit pin is the preview build of that PR.

How it was reproduced. The script of #32591, unchanged, prints BUG REPRODUCED on 1.4.3-canary.1: the breakpoint on line 5 pauses at module code@5:1 with payload in its temporal dead zone. test/cli/inspect/inspect-brk.test.ts fails 30 of 33 tests on that build and passes 33 of 33 on this branch (debug build, Linux x64).

PR: #44691

…tor tests

With a client connected, the VM of the inspector server is not destroyed
at exit. Under BUN_DESTRUCT_VM_ON_EXIT and LeakSanitizer an inspectee then
reports two leaks of that VM (its Bun.stderr and the upgrade Request) and
exits with 134, also under --inspect-wait. These tests check the exit code
of the inspectee.
Comment thread src/jsc/Debugger.rs Resolved
Comment thread src/jsc/Debugger.rs Resolved
Comment thread src/jsc/VirtualMachine.rs Resolved
Comment thread src/jsc/bindings/BunDebugger.cpp Resolved
@robobun

robobun commented Oct 7, 2026

Copy link
Copy Markdown
Collaborator Author

The four comments that the review flagged are one line each now (2647ed2). The threads are resolved.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Inspector reports top-level breakpoint before previous lexical initialization completes

1 participant